C++0x to propozycja nowego standardu języka C++. Została ona zaakceptowana przez komitet ISO 12 sierpnia 2011 roku. Tak oto narodził się C++11. Ponieważ zawiera on dużo zmian i nowości, zarówno po stronie samego języka, jak i biblioteki standardowej, warto się z nimi zapoznać już teraz.
Stopień trudności: 3
Dowiesz się:
Powinieneś wiedzieć:
Niniejszy odcinek cyklu przedstawiającego możliwości C++11 został całkowicie poświęcony dwóm ważnym zagadnieniom: referencjom prawostronnym (ang. rvalue-reference) oraz idealnemu przekazywaniu (ang. perfect forwarding). Wybór oczywiście nie jest przypadkowy. Oba tematy są ze sobą bardzo silnie powiązane. Pojawienie się referencji prawostronnych otwiera furtkę do idealnego przekazywania.
Przed przystąpieniem do lektury zaleca się czytelnikowi dokładne przypomnienie sobie informacji o referencjach lewostronnych (ang. lvalue-reference), w C++03 zwanych po prostu ,,referencjami''. Ponieważ wiele z omawianych technik jest szczególnie przydanych podczas tworzenia szablonów, biegłe ich stosowanie znacznie ułatwi czytanie wybranych fragmentów.
W przykładach stosowane są techniki omawiane w poprzednich częściach cyklu, wszędzie gdzie jest to możliwe. Ma to na celu przyzwyczajenie programisty do nowych elementów i rozszerzeń składni. Zachęca się czytelnika do przypomnienia sobie prezentowanego wcześniej materiału. Intuicyjne posługiwanie się tą wiedzą ułatwi czytanie tego odcinka.
Podobnie jak w poprzednich częściach do kompilowania przykładów użyty został kompilator GCC w wersji 4.6. Jest to szczególnie ważnie, gdyż starsze kompilatory mogą nie posiadać jeszcze wsparcia dla referencji prawostronnych, będących podstawą tego odcinka. Gwoli ścisłości należy zaznaczyć, iż wsparcie ze strony GCCa 4.6 również nie jest pełne (brak referencji prawostronnej do *this), jednak na potrzeby wyjaśnienia najważniejszych mechanizmów oraz idei jest zupełnie wystarczające.
|
Kompilowanie przykładów
Do kompilowania przykładów należy użyć kompilatora GCC w wersji 4.6, ustawiając standard języka na C++0x: g++-4.6 -Wall -std=c++0x src.cpp Większość prezentowanych fragmentów kodu NIE będzie działać na starszych wersjach, które miały minimalne lub żadne wsparcie dla C++0x. W przypadku nowszych wersji GCC niektóre przykłady mogą wymagać drobnych modyfikacji. Pamiętajmy, że w chwili wydania GCC 4.6 C++0x nie był zatwierdzonym standardem. Oprócz tego GCC nie jest jeszcze w pełni zgodny z propozycją standardu języka. |
Na początek przedstawiony zostanie rys historyczny referencji prawostronnych. Pomysł jest nienowy. Możliwość przenoszenia obiektów, zamiast ich kopiowania, zainspirowała twórców szablonu std::auto_ptr jeszcze w latach '90. Stworzenie inteligentnego wskaźnika, o semantyce przenoszenia okazało się jednak zadaniem nietrywialnym.
Aby nieco skrócić opis, poczyniono pewne uproszczenia. W dalszych rozważaniach skoncentrujemy się na konstruktorach. Operator przypisania zostanie pominięty - jego implementacja jest (logicznie) podobna.
Już pierwsze podejście do stworzenia konstruktora ,,kopiującego'' nastręcza trudności - przecież sygnatura powinna mieć postać ,,z const'', czyli Typ(const Typ& other) co z założenia przekreśla możliwość bezpośredniej zmiany podanego obiektu źródłowego. Uprzedzając pytania i wątpliwości, warto wyjaśnić ,,dlaczego const'' i ,,dlaczego referencja''?
Gdyby nie było tych ograniczeń, możliwe byłoby tworzenie konstrukcji powodujących potencjalnie nietrywialne efekty uboczne, a czasem wręcz absurdalnych. Przykładowo, gdyby nie było ,,referencji'', przekazanie obiektu do konstruktora kopiującego powodowałoby konieczność stworzenia kopii obiektu źródłowego, co z kolei powodowałoby zawołanie konstruktora kopiującego, itd... Gdyby zaś referencja nie była const, co prawda moglibyśmy zmieniać obiekt źródłowy, ale mając konstruktor postaci Typ(Typ&) niemożliwe byłoby tworzenie kopii z obiektów tymczasowych, jak na listingu 1a. Choć konstruktor takiej postaci jest w C++ dopuszczalny, nie jest zalecany. Aby obsługiwać wszystkie, możliwe przypadki użycia konstruktor kopiujący musi być postaci Typ(const Typ&).
|
struct X { explicit X(int v); X(/*const*/ X&); // uwaga - brak const! }; void doSth(X); // ... doSth{ X{2} }; // błąd - brak odpowiedniego konstruktora |
| Listing 1a: Problem z tworzeniem kopii obiektu tymczasowego w przypadku konstruktora ,,bez const''. |
Dlaczego więc nie umożliwić referencji nie-const do obiektów tymczasowych? Najprościej odpowiedzieć na to pytanie przykładem z czasów pierwszych implementacji kompilatorów C++. Zakładając istnienie modyfikowalnych referencji do obiektów tymczasowych, przeanalizujmy kod z listingu 1b. Intencje autora są jasne - zmienna c ma zostać zwiększona o jeden. Okazuje się jednak, że zmienna ta nigdy nie została zmieniona i asercja zatrzymuje program! Co więc poszło nie tak? Funkcja inc() wymaga argumentu long&, ale dostała char, więc dokonywana jest automatyczna konwersja i pobierana modyfikowalna referencja do obiektu tymczasowego. Referencja ta nosi nazwę v. Właśnie ,,niechcący'' zmieniliśmy obiekt tymczasowy. Błąd wysoce nietrywialny w znalezieniu, prawda? Tak więc obiekty tymczasowe są niemodyfikowalne w C++03.
|
void inc(long& v) { ++v; } // ... char c='a'; inc(c); assert(c=='b'); // Oops... |
| Listing 1b: ,,Błędny'' kod w świecie modyfikowalnych referencji na obiekty tymczasowe. |
Jakie mamy więc dalsze możliwości stworzenia idealnego auto_ptr? Może słowo kluczowe mutable zdałoby egzamin? Niestety i to rozwiązanie nie zdaje egzaminu. Gdyby auto_ptr przechowywał wskaźnik jako mutable Typ *, kompilator nie byłby w stanie wykrywać sytuacji niezgodnych z zamierzeniami autora, jak na przykład modyfikacja obiektów stałych (listing 1c).
|
void doSth(std::auto_ptr<int>); const std::auto_ptr<int> answer{new int{42}}; doSth(answer); // Oops... |
| Listing 1c: Gdyby wskaźnik na dane był oznaczony jako mutable, kod by się skompilował. |
Aby ominąć opisane przypadki, postanowiono wykorzystać pewien trik językowy. Jeśli obiekt nie jest faktycznie stały, można go niejawnie skonwertować do innego typu, za pomocą niestałego operatora konwersji. Wprowadzono więc typ pomocniczy: auto_ptr_ref, który przechowuje zarządzany obiekt. auto_ptr, potrafi się skonwertować do tego obiektu niejawnie oraz posiada konstruktor tworzący obiekt z danego typu.
Opisane rozwiązanie jest już trzecim podejściem komitetu standaryzacyjnego do problemu. Niestety rozwiązanie nadal nie jest idealne. Kłopoty pojawiły się w miejscu, które jawnie jest obsługiwane... niestety nie zawsze poprawnie. Mowa o konwersjach wskaźników klasy pochodnej na bazową. Na listingu 1d pokazano przykładowy fragment kodu operujący na obiektach auto_ptr pokazujących na klasy bazowe i pochodne. Sam szablon pozwala na przypisanie obiektu klasy pochodnej do bazowej, tak jak pokazano to w przypadku (1).
|
struct Base {}; struct Derived: public Base {}; auto_ptr<Derived> source() { return auto_ptr<Derived>{new Derived}; } void sink(auto_ptr<Base>) { } // przypadek (1) { auto_ptr<Derived> d{ source() }; auto_ptr<Base> b{d}; } // przypadek (2) { sink( source() ); } |
| Listing 1d: auto_ptr a klasy pochodne i bazowe. Wbrew zamysłowi autorów tylko przypadek (1) jest poprawny. |
Problem zaczyna się, kiedy chcemy jednocześnie niejawnie przypisać obiekt i zmienić jego właściciela tak jak w przypadku (2). C++ pozwala jedynie na jedną, niejawną konwersję, jaką kompilator może wykonać za użytkownika. Niestety w tym przypadku potrzeba minimum dwóch konwersji - niejawnej konwersji do auto_ptr_ref oraz niejawnej zmiany typu.
Na zakończenie mała ciekawostka - kto zauważył problem na listingu 1d? Konkretnie chodzi o przypadek (1), gdyż jak już wiemy przykład (2) nie skompiluje się. Otóż klasa bazowa nie posiada wirtualnego destruktora, jednak obiekt klasy pochodnej jest niszczony przez wskaźnik na obiekt Base.
Dużo ,,magii'', dużo problemów, lata pracy komitetu standaryzacyjnego. Kilka rozwiązań, coraz więcej ulepszeń, coraz mniej przypadków niedziałających. Niestety problem nadal nie został rozwiązany. Skoro tak trudno jest zaimplementować semantykę przenoszenia, korzystając z gotowych elementów języka, może dałoby się podejść do problemu z innej strony?
Komitet standaryzacyjny zaadresował kwestię auto_ptr w sposób ogólny, dając programiście do ręki gotowe narzędzie umożliwiające proste tworzenie własnych klas z semantyką przenoszenia. Zastosowano do tego nowy typ referencji.
Słowo ,,referencja'' było w C++03 synonimem ,,referencji lewostronnej'' (Typ&). Pokazywały one na obiekty nazwane. W przypadku referencji stałych mogły też pokazywać na obiekty tymczasowe, jednak takowe były wtedy niemodyfikowalne. Na listingu 1b pokazane zostało, dlaczego obiekty tymczasowe nie powinny być modyfikowalne. Jest jednak pewien wyjątek - czasami naprawdę chcemy, aby były one modyfikowalne. Dobrym przykładem jest klasa auto_ptr.
Aby rozróżnić przypadek, kiedy obiekt tymczasowy może być modyfikowany oraz zachować dotychczasową semantykę referencji wprowadzono nowy typ - referencje prawostronne oznaczane jako Typ&&. Aby oba rodzaje referencji ze sobą nie kolidowały, wymagane są odpowiednie reguły dopasowywania - w skrócie:
Podane reguły są zgodne z intuicją. Nieco dziwna, na pierwszy rzut oka może wydawać się ostatnia z nich. Przydaje się to jednak do jawnego przenoszenia obiektów, które były wykorzystywane wcześniej w kodzie. Podobnie ma się sprawa z elementami kontenerów, podczas ich przenoszenia (np. po realokacji wewnętrznych buforów).
Zastosowanie reguł wiązania referencji rvalue i lvalue pokazuje listing 2a. Pierwsze 6 wiązań zawsze powoduje wiązanie z referencją lvalue, ponieważ mamy do czynienia ze zmiennymi nazwanymi. Pośród kolejnych 3 przypadków do referencji rvalue wiążą się wartości tymczasowe (przypadek 1) oraz jawnie zwracane referencje rvalue (przypadek 3). Ostatnie 3 przypadki wiążą się do niemodyfikowalnej referencji lvalue, ponieważ zwracane wartości/referencje są stałe. Gdyby istniała specjalizacja foo(const A&&) przypadek 1 i 3, z ostatniej grupy, związałyby się właśnie z nią.
|
struct A {}; void foo(const A&); // #1 void foo(A&&); // #2 A source_rvalue(); A& source_ref(); A&& source_rvalue_ref(); const A source_const_rvalue(); const A& source_const_ref(); const A&& source_const_rvalue_ref(); // ... A a; A& ra =a; A&& rra=std::move(a); A ca2; const A ca{}; const A& rca =ca; const A&& rrca=std::move(ca2); foo(a); // #1 foo(ra); // #1 foo(rra); // #1 foo(ca); // #1 foo(rca); // #1 foo(rrca); // #1 foo(source_rvalue()); // #2 foo(source_ref()); // #1 foo(source_rvalue_ref()); // #2 foo(source_const_rvalue()); // #1 foo(source_const_ref()); // #1 foo(source_const_rvalue_ref()); // #1 |
| Listing 2a: Zastosowanie reguł wiązania lvalue i rvalue. Przykład zaczerpnięty z artykułu ,,A Proposal to Add Move Semantics Support to the C++ Language'' autorstwa Howarda E. Hinnanta, Petera Dimova oraz Dave'a Abrahamsa. |
Mówiąc o referencjach prawostronnych warto wspomnieć o jeszcze jednym ciekawym przypadku. W pewnych sytuacjach kompilator jest w stanie stwierdzić, że nazwana zmienna nie będzie już dłużej używana i można ją przenieść, zamiast kopiować. Przykładem takiego miejsca jest wartość zwracana z funkcji/metody. Na listingu 2b przedstawiono typowy przykład funkcji, umożliwiającej takie założenie.
|
A foo(void) { A a; return a; // return std::move(a) } |
| Listing 2b: Przenoszenie nazwanej zmiennej. |
Uważny czytelnik może zwrócić uwagę, iż dla funkcji foo() z listingu 1f, możliwe jest także zoptymalizowanie kodu przez kompilator za pomocą NRVO (ang. Named Return Value Optimization). W takim przypadku programista nie poczuje różnicy w wydajności po zastosowaniu semantyki przenoszenia. Istnieją jednak przypadki, kiedy kompilator nie jest w stanie przeprowadzić tego tupu optymalizacji samoczynnie. Typowym przykładem takiej sytuacji jest zwracanie innego obiektu, w zależności od ścieżki wykonania kodu (patrzy listing 2c). Semantyka przenoszenia jest bardziej ogólna i pozwala na optymalizacje zwracania wartości również w takich przypadkach.
|
A bar(void) { A a1; A a2; return (rand()%2)?a1:a2; // return std::move((rand()%2)?a1:a2;) } |
| Listing 2c: Funkcja, dla której NRVO nie jest łatwo realizowalne. |
Kilkukrotnie w przykładach pojawiło się wywołanie funkcji std::move. Do tej pory zostało jedynie powiedziane, iż powoduje ona przenoszenie obiektu. Pora nieco dokładniej wyjaśnić jego działanie. Czytając reguły stosowanie referencji prawostronnych, widzimy że wartość nazwana nie jest traktowana jako tymczasowa. Ma to oczywiście uzasadnienie w postaci bezpieczeństwa - jeśli spróbowalibyśmy taki obiekt ,,niejawnie'' przenieść, kompilator powstrzyma nas z odpowiednim komunikatem o błędzie. Czasami jednak zdarzają się przypadki, w których chcemy świadomie przenieść nazwany obiekt, ponieważ wiemy, iż nie będzie on nam już więcej potrzebny. Do zasygnalizowania takiej sytuacji służy std::move. Na listingu 2d przedstawiono przykład poprawnego użycia tejże funkcji.
|
struct SomeMovable; // przenoszalna, nie kopiowalna klasa void foo(SomeMovable m); // ... SomeMovable m; m.doSth(); //foo(m); // błąd - niejawne przenoszenie nazwanej zmiennej foo(std::move(m)) // ok - jawne przenoszenie nazwanej zmiennej //m.doSth(); // błąd - m nie może być dalej używane |
| Listing 2d: Zastosowanie std::move do jawnego przenoszenia obiektu. |
C++03 pozwalał na wygodne składanie obiektów z części poprzez system przeciążonych operatorów. Chyba najbardziej popularnym przykładem jest tu łączenie stringów za pomocą operatora dodawania (+) (listing 2e). Jawnie tworzone są 4 obiekty tymczasowe. Niestety nawet stosując sprytne grupowanie operacji, do dokończenia potrzebujemy jeszcze co najmniej 3 niejawnych obiektów tymczasowych - łącznie "1" i "2", łącznie "3" i "4" oraz łączenie "12" i "34". Dopiero taki obiekt możemy przypisać do zmiennej out.
|
string out=string("1")+string("2")+string("3")+string("4"); |
| Listing 2e: Łączenie napisów. Ile obiektów tymczasowych powstanie? Kto za to wszystko zapłaci? |
Modyfikacja klasy string tak, by wykorzystywała możliwości C++11, otwiera przed kompilatorem nowe możliwości. Dostarczając odpowiednich operatorów przenoszenia oraz przeciążeń operatora + wykorzystujących referencje prawostronne umożliwiamy znaczące zmniejszenie liczby obiektów tymczasowych. Kompilator może wykorzystać nowe informacje i operatory, by ,,dołączyć'' nowe elementy do powstałego już obiektu tymczasowego. Dzięki temu kolejne dodawania będą tak naprawdę jedynie dodaniem nowej porcji danych do już istniejącego napisu (co oczywiście może się wiązać za realokacją poprzednio przydzielonego bufora wewnętrznego). Na koniec obiekt tymczasowy zostanie przeniesiony na nazwaną zmienną wynikową. Stosując takie podejście, nie użyjemy żadnego, niejawnego obiektu tymczasowego (obiektem do którego ,,dopisujemy'' będzie jeden z jawnie utworzonych obiektów tymczasowych) oraz unikniemy jednego kopiowania.
C++11 wprowadza referencje prawostronne, a więc semantykę przenoszenia. Wymaga to wyjaśnienia reguł współegzystowania operacji kopiowania oraz przenoszenia.
C++03 tworzy domyślnie operator przypisania i konstruktor kopiujący dla każdej klasy. W praktyce jest to proste, gdyż wymaga stworzenia dokładnej kopii obiektów prostych (ang. POD - Plain Old Data) oraz wywołania stosownych operacji kopiowania dla obiektów złożonych.
Podobnie wygląda to w przypadku konstruktora przenoszącego oraz przenoszącego przypisania. Jeśli odpowiednie operacje są dostępne dla elementów składowych i klas bazowych (lub są one trywialne), można automatycznie utworzyć operatory niezbędne dla semantyki przenoszenia automatycznie.
Należy pamiętać, iż pojawienie się nowego sposobu przekazywania parametrów oznacza kolejne możliwości pojawienia się niejednoznacznego kodu. Listing 2f przedstawia przykład niejednoznacznego programu. W przypadku pojawienia się tego typu sytuacji kompilator zatrzyma się ze stosownym komunikatem o błędzie.
|
struct CopyableMovable; // kopiowalny i przenoszalny typ void foo(CopyableMovable); void foo(CopyableMovable&&); // ... foo( CopyableMovable{} ); // Oops... |
| Listing 2f: Niejednoznaczny kod - program się nie skompiluje. |
Aby ułatwić życie programiście i jednocześnie uniknąć typowych problemów komitet zdefiniował odpowiednie reguły dotyczące automatycznego generowania konstruktorów i operatorów zarówno kopiujących, jak i przenoszących. Pełna lista owych zasad zajmuje kilkanaście stron. Zdecydowaną większość ,,typowych'' przypadków można jednak podsumować w kilku zdaniach.
Reguły tworzenia operacji przenoszenia są podobne do dobrze znanych już programistom reguł tworzenia operacji kopiowania. Programiście znającemu owe zasady łatwo będzie przyswoić nowe. Upraszczając można powiedzieć, iż operacje kopiowania/przenoszenia mogą zostać wygenerowane, jeśli wszystkie elementy składowe i klasy bazowe mogą zostać skopiowane/przeniesione.
Spośród istotniejszych wyjątków od wspomnianych reguł warto wspomnieć, iż żadne z podanych operacji nie zostaną wygenerowane automatycznie, jeśli programista jawnie zdefiniuje przynajmniej jeden z podanych elementów:
Tworzenie własnych typów obsługujących przenoszenie ma dużo zalet. Jedną z nich jest znaczne zmniejszenie kosztu operowania na nich w kontenerach potrafiących wykorzystać semantykę przenoszenia.
Przykładem takiego kontenera w bibliotece standardowej jest std::vector. Oprócz oczywistych zalet poprawy wydajności, podczas przekazywania elementów jako argumenty metod kontenera, pojawia się znaczący przyrost szybkości w przypadku pewnych operacji wykonywanych na tym kontenerze.
Jako pierwszy przykład użyty zostanie wektor napisów - vector<string>. Ogromną zaletą wektora jest jego możliwość dynamicznego zwiększania rozmiaru w miarę dodawania nowych elementów. Oczywiście w przyrodzie nie ma nic za darmo. Alokacja nowego obszaru pamięci oznacza konieczność skopiowania doń zawartości starego przed jego zwolnieniem. Z pomocą przychodzi nam więc C++11 i przenoszenie obiektów. Po co kopiować obiekt, który zaraz ma być zniszczony (stara kopia)? Lepiej go przenieść zaoszczędzając czas i pamięć! Na rysunku 2a przedstawione zostały czasy dodawania różnej liczby elementów do wektora w wersji kopiującej (C++03) oraz przenoszącej (C++11). Dodawanie elementów odbywało się zawsze na końcu kontenera, za pomocą metody push_back() (przypadek optymistyczny). Kod testowy przedstawiono na listingu 2g.
|
const string data="some string for test purposes"; vector<string> v; Timer t; for(unsigned long i=0; i<cnt; ++i) v.push_back(data); cout<<t.elapsed()<<endl; |
| Listing 2g: Kod testowy do pomiaru czasu dodawania dla klasy std::string. |
|
| Rysunek 2a: Porównanie czasów wstawiania elementów typu std::string do kontenera std::vector z kopiowaniem (C++03) i przenoszeniem (C++11). |
Oba wykresy są monotonicznie rosnące, ponieważ dodawanie nowych elementów zawsze wymaga czasu - niezależnie od metody wykonywania tej operacji. Na szczególną uwagę zasługują duże ,,skoki'' czasu dodawania, widoczne w okolicach 1000 i 2000 elementów. To właśnie w tych miejscach wewnętrzny bufor ulega zwiększeniu. W przypadku kopiowania danych objawia się to znacznym spowolnieniem. Dzięki przenoszeniu w C++11 spadki wydajności w tych miejscach są niemal niezauważalne.
Klasa string jest tylko jednym z możliwych przykładów. Analogiczne pomiary czasu wykonania przeprowadzono dla własnej klasy BigData przechowującej w sobie, alokowaną na stercie, tablicę 512 bajtów (inicjowanych przypadkowymi danymi). Kopiowania obiektu wiąże się z narzutem wykonania kopii całego bufora do obiektu docelowego. Przenoszenie wymaga jedynie przepisania wskaźnika i zmiennej informującej o rozmiarze wewnętrznego bufora oraz wyzerowania wskaźnika obiektu źródłowego. Eksperyment ilustruje rysunek 2b.
|
vector<BigData> v; Timer t; for(unsigned long i=0; i<cnt; ++i) v.push_back( BigData(512) ); cout<<t.elapsed()<<endl; |
| Listing 2h: Kod testowy do pomiaru czasu dodawania dla klasy BigData. |
|
| Rysunek 2b: Porównanie czasów wstawiania elementów typu BigData (bufor 512 bajtów) z kopiowaniem (C++03) i przenoszeniem (C++11). |
Wykres ujawnia te same cechy oraz punkty charakterystyczne, co kopiowanie niedużego napisu (rysunek 2a). Przyspieszenie pojawia się więc zarówno dla małych, jak i dużych obiektów. Im droższe jest wykonywanie kopii obiektu trzymanego w kolekcji, tym większy zysk ze wprowadzenia semantyki przenoszenia wartości.
Tworząc kopiujący operator przypisania, należy zachować szczególną ostrożność pod względem wyjątków. W miarę możliwości (i potrzeb) kod powinien gwarantować niezmienność obiektu docelowego, w przypadku zgłoszenia wyjątku podczas kopiowania (gwarancja silnego bezpieczeństwa). Przykład takiego kodu przedstawiono na listingu 2i. Obiekt ten najpierw tworzy kopię obiektu źródłowego. Operacja ta oczywiście może zgłosić wyjątek. Po tej operacji następuje niezgłaszająca wyjątków podmiana zawartości obiektu docelowego z tymczasowym. Gwarantuje to, iż w przypadku zgłoszenia wyjątku, obiekt docelowy pozostanie niezmodyfikowany. Choć zachowanie takie nie jest obowiązkowe, bardzo ułatwia korzystanie z obiektów danej klasy i pozwala uniknąć masy nietrywialnych błędów związanych z nieokreślonym stanem wewnętrznym obiektu po nieudanym przypisaniu.
|
struct SafeCopy { // ... const SafeCopy &operator=(const SafeCopy &other) { if(this!=&other) { SafeCopy tmp(other); tmp.swap(*this); } return *this; } void swap(SafeCopy &other) { // ... } // ... }; |
| Listing 2i: Zalecana metoda implementacji operatora przypisania. |
Warto się zastanowić, jakie gwarancje bezpieczeństwa powinna zapewniać semantyka przenoszenia wartości. ,,Zwykłe'' przeniesienia, jakie pokazano na listingu 2j, nie sprawią kłopotu. Problem zaczyna się w przypadku kolekcji. Załóżmy, że przenoszenie wartości może zgłosić wyjątek. W takim przypadku po realokacji wewnętrznego bufora std::vector, podczas przenoszenia obiektów ze starego obszaru pamięci na nowy, zgłoszenie wyjątku spowoduje nieokreślony stan wewnętrzny, z którego nie da się wrócić - część (już przeniesionych) obiektów znajdzie się w nowym buforze, reszta nadal w starym.
|
Data source(void) { return Data{}; } // ... Data p=source(); // przypadek 1 Data x=std::move(p); // przypadek 2 |
| Listing 2j: ,,Zwykłe'' przenoszenia wartości. |
Kontener std::vector jest oczywiście tylko przykładem. Analogiczny kłopot pojawi się przenaszalnej klasie, zawierającej dwa pola, których przenoszenie może zgłosić wyjątek. Problem jest więc ogólny.
Jak widać, na przykładzie powyższych rozważań, operacje przenoszenia wartości i wyjątki lubią się mniej więcej tak samo, jak wyjątki i destruktory. Teoretycznie wyjątek można tam zgłosić, ale w praktyce jest to bardzo zły pomysł. W teorii nie ma różnicy między teorią a praktyką, ale w praktyce jest...
Jeśli nie ma możliwości stworzenia operacji przeniesienia własności, która nie będzie zgłaszała wyjątków, lepiej nie implementować jej wcale. Alternatywą jest wykorzystanie pamięci dynamicznej do przechowywania wewnętrznych danych (lub całego obiektu) i przekazywanie wskaźników. Najlepiej w takim przypadku skorzystać z pomocy inteligentnego wskaźnika std::unique_ptr, prezentowanego w poprzednim odcinku niniejszego cyklu.
Tak więc wyjątków z operacji przenoszenia zgłaszać nie można. Operacja ta musi się zawsze powieść. Jak więc może wyglądać obiekt źródłowy, po jego przeniesieniu?
Jak przykład do rozważań weźmy klasę std::string. Przykładowa operacja przenoszenia może ,,zabrać'' wskaźnik do danych obiektu źródłowego (razem z jego własnością) obiektowi źródłowemu zaś przydzielić nowy, pusty bufor na dane (odpowiednik ""). Ale zaraz! Tego drugiego akurat zrobić nie może - przecież przydzielanie pamięci może zgłosić wyjątek!
W tym właśnie momencie przechodzimy płynnie do zasadniczego pytania ,,o życie, wszechświat i całą resztę'' - w jakim stanie można ,,zostawić'' obiekt źródłowy po przeniesieniu własności? Żeby można było obiekt przenieść, musi on najpierw zostać skonstruowany. Skoro zaś został skonstruowany, na pewno musi być również kiedyś zniszczony. Programista musi więc zapewnić możliwość bezpiecznego zniszczenia takiego obiektu.
Geneza referencji prawostronnych to modyfikowalne obiekty tymczasowe (wtedy, kiedy jest to bezpieczne i świadome ich użycie). Odpowiada to przykładowi 1 z listingu 2j. Życie obiektu źródłowego kończy się tu zaraz po transferze własności. Możliwość bezpiecznego zniszczenia obiektu źródłowego, po transferze własności, jest więc podstawą. W praktyce przeważnie oznacza to konieczność zdefiniowana stanu ,,bez stanu''. Przykładem jest tu wskaźnik ustawiony na nullptr - niepokazujący na nic.
Poprzednio przytaczane przykłady niejednokrotnie korzystały z przenoszenia nazwanych zmiennych, za pomocą std::move(). Teoretycznie daje to możliwość wykonywania, na poziomie składni języka, dalszych operacji na przeniesionym obiekcie (patrz przykład 2d). Jakie więc inne operacje są możliwe po przeniesieniu własności obiektu?
Odpowiadając na to pytanie warto spojrzeć na domyślną implementację standardowej funkcji std::swap(), korzystającej z semantyki przenoszenia, jeśli to tylko możliwe (listing 2k). Widać, że operuje ona na przeniesionym obiekcie tymczasowym a, potem zaś b. Obiekty te są nadpisywane, po wykonaniu przeniesienia. Aby więc w pełni wykorzystać możliwości przenoszenia własności, konieczne jest też zdefiniowanie operatora przypisania z referencji prawostronnej.
|
template<typename T> void swap(T &a, T &b) { T tmp( std::move(a) ); a=std::move(b); b=std::move(tmp); } |
| Listing 2k: Implementacja std::swap(), używająca semantyki przenoszenia. |
Tak więc obiekty używające semantyki przenoszenia muszą być zniszczalne oraz nadpisywalne. A co z innymi metodami klasy? Czy można je wołać bezpiecznie zależy już od implementacji danej klasy. W ogólności zakłada się, iż na obiekcie takim nie można wykonywać żadnych operacji, poza dwoma wymienionymi, do czasu przypisania mu nowej wartości.
Warto podkreślić, że implementacja std::swap() z listingu 2k jest ogólna i może być również wykorzystana w przypadku obiektów kopiowalnych. Jeśli obiekt nie posiada zdefiniowanego operatora przypisania z rvalue, referencja rvalue zwiąże się ze stałą referencją prawostronną operatora kopiującego (patrz listing 2a). Oznacza to, że w przypadku braku możliwości przeniesienia, zostaną stworzone odpowiednie kopie, dokładnie tak samo, jak miało to miejsce w przypadku std::swap(), znanego z biblioteki standardowej C++03.
W kodzie pisanym w C++03 pojawiały się czasem różne ,,zabiegi'' mające na celu ograniczenie tworzenia zbędnych kopii obiektów przekazywanych jako parametry wywołań. Często pomocny okazywał się tu kompilator, samodzielnie optymalizujący obiekty nienazwane. Częstokroć jest to jednak za mało. Przekazanie parametru do konstruktora i tak oznacza przeważnie co najmniej jedną (potencjalnie) zbędną kopię. Jeśli okaże się, iż jest to parametr, który klasa przekazuje ,,głębiej'' do implementacji liczba kopii może dodatkowo wzrosnąć.
Liczbę kopii zwyczajowo ograniczało się poprzez przekazywanie parametrów, które należało skopiować jako pola prywatne, poprzez stałe referencje lewostronne - const Typ&. Wprowadzenie C++11 i semantyki przenoszenia umożliwiło łatwe i praktycznie darmowe zmienianie właściciela obiektu. Otworzyło drogę do perfekcyjnego przekazywania parametrów.
Pierwszy bohater tej części został już przedstawiony. Mowa oczywiście o funkcji std::move(). Stosując referencje prawostronne oraz wspomnianą funkcję można znacznie ulepszyć przekazywanie parametrów do metod i konstruktorów. Listing 3a przedstawia kod w C++03. Zoptymalizowaną wersję, napisaną w C++11, przedstawiono na listingu 3b. Kod w C++03 tworzy obiekt tymczasowy, po czym przekazuje go przez stałą referencję lewostronną do konstruktora i tam wykonuje kopię do docelowego miejsca. Kod w C++11 tworzy obiekt tymczasowy, po czym przekazuje go przez referencję prawostronną i za pomocą std::move() przenosi do obiektu bazowego - powstaje zatem tylko jeden obiekt.
|
struct Sth { explicit Sth(const MrHeavy &mrh): mrh_(mrh) { } private: MrHeavy mrh_; }; // ... Sth s( (MrHeavy()) ); |
| Listing 3a: Przekazywanie parametrów w C++03. |
|
struct Sth { explicit Sth(MrHeavy &&mrh): mrh_( std::move(mrh) ) { } private: MrHeavy mrh_; }; // ... Sth s( MrHeavy{} ); |
| Listing 3b: Zoptymalizowany dla C++11 kod z listingu 3a. |
Na std::move() przygoda przekazywania parametrów się jednak nie kończy. Definiując szablon, jak na listingu 3c, trzeba pamiętać, iż T jest parametrem, więc nie jest nam znany jego konkretny typ w momencie implementowania samego szablonu. Pierwsze użycie szablonu foo() przynosi oczekiwany skutek, ponieważ przekazywana wartość jest referencją prawostronną. Niestety drugie wywołanie przekazuje referencję lewostronną (zmienna nazwana) co skutkuje błędem logicznym, który NIE zostanie zgłoszony w czasie kompilacji, choć nazwana zmienna może zostać przeniesiona (zależnie od implementacji bar()).
|
template<typename T> void foo(T &&t) { bar( std::move(t) ); } // ... foo( Movable{} ); // (1) ok Movable m; foo(m); // (2) Houston - you know what... |
| Listing 3c: Przykładowy szablon - jakiego typu jest T? |
Więc typ T sam może być referencją. Aby uniknąć problemów referencji-na-referencję, wprowadzono następujące zasady ,,zwijania'' referencji r/l-value:
Czy możliwe jest zatem napisanie uniwersalnego szablonu, przekazującego swoje parametry do kolejnych wywołań (tu: bar())? Z pomocą przychodzi nam biblioteka standardowa, z szablonem std::forward(). Wspomniana funkcja posiada dwie implementacje, pozwalające na bezpieczne przekazywanie parametrów, respektując ich faktyczny typ. Znaczy to, że w przypadku referencji prawostronnej przekazana zostanie referencja prawostronna, zaś w przypadku referencji lewostronnej - lewostronna. Poprawiony przykład z listingu 3c przedstawiono na listingu 3d. Uwagę przyciąga fakt, iż std::forward(), w przeciwieństwie do std::move, wymaga podania pełnego typu parametru, aby dedukcja mogła odbyć się pomyślnie.
|
template<typename T> void foo(T &&t) { bar( std::forward<T&&>(t) ); } // ... foo( Movable{} ); // ok - rvalue Movable m; foo(m); // ok - lvalue |
| Listing 3d: Poprawiony kod z listingu 3c, pokazujący idealne przekazywanie. |
Idealne przekazywanie doskonale się sprawdza wszędzie tam, gdzie podawane są parametry mające być zapisane wewnątrz wywołania. Uwaga ta tyczy się również klas i ich konstruktów. Mechanizm ten znajdzie więc zastosowanie w niemal każdej klasie pisanej w C++11. Nawet bardzo generyczny kod może zyskać dzięki takiemu podejściu, ze względu na możliwość jednoczesnego używania szablonów zmiennej długości oraz przekazywania. Na listingu 3e pokazano przykładową klasę wyjątku, wymagającą dwóch parametrów konstruktora (nazwa pliku i numer linii) oraz opcjonalnie dodatkowych, podanych przez użytkownika elementów komunikatu o błędzie. Konstruktor skleja je razem w pojedynczy napis, korzystając z obiektu pomocniczego typu std::stringstream, wynik zaś przekazuje do klasy bazowej. Ten wygodny zabieg zwalnia programistę z dopisywania nowego konstruktora za każdym razem, kiedy użytkownik potrzebuje dodać nieco inne informacje do komunikatu o błędzie.
|
class MyException: public runtime_error { public: template<typename ...Args> MyException(const char *file, unsigned long line, Args&&...args): runtime_error( concat(stringstream{}, file, ":", line, std::forward<Args&&>(args)...) ) { } virtual ~MyException(void) throw() { } private: template<typename Head, typename ...Tail> static string concat(stringstream &&ss, Head &&h, Tail&&...t) { ss<<std::forward<Head&&>(h); return concat(std::move(ss), std::forward<Tail&&>(t)...); } static string concat(stringstream &&ss) { return ss.str(); } }; // ... cout<< MyException{__FILE__, __LINE__}.what() <<endl; // -> myex.cpp:32 cout<< MyException{__FILE__, __LINE__, string{" oops"}}.what() <<endl; // -> myex.cpp:34 oops const string call("read()"); cout<< MyException{__FILE__, __LINE__, " ", call, " returned ", -42}.what() <<endl; // -> myex.cpp:37 read() returned -42 cout<<call<<endl; // -> read() |
| Listing 3e: Klasa wyjątku ,,składającego'' komunikat o błędzie wraz z przykładowymi wywołaniami konstruktora. |
Referencja prawostronna to brzytwa i jak każde potężne narzędzie może być użyta dla dobra (,,Wydajność, inc.'') jak i zła (,,Błąd Segmentacji, sp. z o.o.''). Ponieważ umiejętne stosowanie semantyki przenoszenia wymaga nieco praktyki warto poświęcić chwilę uwagi temu, jak pozostać po jasnej stronie mocy. W niniejszym fragmencie skupimy się więc na częstych błędach, jakie łatwo popełnić podczas pierwszych prób.
Wpadając w wir zwracania wartości z funkcji/metod przez referencje można napisać kod podobny do przedstawionego na listingu 4a. Niestety kod ten nie jest poprawny. Problemem jest funkcja foo(), a konkretnie zwracanie referencji rvalue pokazującej na zmienną automatyczną wewnątrz funkcji. Po wyjściu z funkcji wartość zwracana pokazuje na niezainicjowany obszar pamięci (np. zwolnioną pamięć dynamiczną) i to z niego następuje kopiowanie do zmiennej m.
|
Movable &&foo(void) // ERROR! { Movable m{"good day"}; return std::move(m); } // ... Movable m=foo(); |
| Listing 4a: Niepoprawne zwracanie referencji do tymczasowego obiektu. |
Auto funkcji foo() chciał tak naprawdę zwrócić obiekt klasy Movable przez wartość, co w tym kontekście spowoduje wywołanie konstruktora przenoszącego. Usunięcie referencji (&&) z typu zwracanego rozwiąże problem. Jawne wymuszanie przenoszenia nie jest też wymagane w przypadku wartości zwracanej (również dla nazwanej zmiennej!) - wywołanie std::move() można więc pominąć. Poprawiony kod przedstawiono na listingu 4b.
|
Movable foo(void) { Movable m{"good day"}; return m; // return std::move(m) } // ... Movable m=foo(); |
| Listing 4b: Poprawiony kod z listingu 4a. |
Kolejny przykład pomyłki, o jaką łatwo przedstawiono na listingu 4c. Klasa MoveStr obudowuje std::string. Posiada konstruktor tworzący obiekt z napisu oraz konstruktor przenoszący. Uważana analiza pokazuje jednak pewne ,,drobne'' uchybienia w implementacji.
|
struct MoveStr { MoveStr(string &&s): s_{s} { } MoveStr(MoveStr &&ims): s_{ims.s_} { } string s_; }; |
| Listing 4c: Niepoprawny kod z semantyką przenoszenia. |
Zacznijmy od konstruktora tworzącego obiekt z referencji rvalue na string. Ponieważ podany jest konkretny typ, wiemy że własność obiektu s można przejąć. Ale czy to właśnie się dzieje? Obiekt s jest nazwany, więc przekazując go jako parametr do pola s_ klasy, przekazujemy go domyślnie jako referencję lewostronną! Oznacza to, że dla obiektu s_ zawołany zostanie konstruktor string(const string&), czyli kopiujący. Aby zawołać konstruktor string(string&&) należy użyć jawnie przenieść obiekt za pomocą std::move(). Właśnie straciliśmy na wydajności. Gdyby naszym polem był wektor, ze znaczną liczbą obiektów spadek wydajności byłby mocno odczuwalny.
Będąc przy konstruktorze warto zaznaczyć jeszcze jeden szczegół. Klasa MoveStr została mocno uproszczona, aby skupić się na meritum problemu. Gdyby klasa miała być faktycznie wykorzystana produkcyjnie należałoby dodać jeszcze jeden konstruktor, budujący ją z obiektów nazwanych - MoveStr(const string&). Warto pamiętać o tym drobiazgu podczas implementacji realnych klas.
Po analizie konstruktora czytelnik prawdopodobnie zauważył już drugi problem. Oczywiście w konstruktorze przenoszącym pojawił się (,,z rozpędu'') dokładnie ten sam problem co poprzednio - wykonujemy kopię, zamiast przenieść własność. Znów spadek wydajności... Ale na tym nie koniec. Błąd ten bowiem ma drugie dno. Drugi błąd, jaki wprowadziliśmy do programu nieuważnie napisanym konstruktorem przenoszącym, jest znacznie bardziej niebezpieczny, gdyż może doprowadzić program do stanu niepoprawnego (i to nieodwracalnie). Wykonując kopię, zamiast przeniesienia własności, spowodowaliśmy zawołanie konstruktora kopiującego - takiego, który ma prawo zgłosić wyjątek. Gdyby taka sytuacja nastąpiła na przykład podczas realokacji pamięci wewnątrz kolekcji lub przenoszenia obiektu naszej klasy jako pola składowego większej struktury wyjątek zostałby zgłoszony w miejscu, o którym już wiemy, że nie powinno tam nigdy nastąpić.
Poprawiony kod przedstawiono na listingu 4d. Warto mu się dobrze przyjrzeć i zapamiętać problem, jaki powstał przez nieuwagę. Czy po takiej lekcji przenoszący operator przypisania na pewno napisalibyśmy poprawnie? Najlepszym podejściem jest pisanie kodu w taki sposób, aby domyślnie wygenerowane operatory przenoszenia były poprawne (podobnie jak robi się z operacjami kopiującymi w C++ od dawna).
|
struct MoveStr { MoveStr(string &&s): s_{std::move(s)} { } MoveStr(MoveStr &&ims): s_{std::move(ims.s_)} { } string s_; }; |
| Listing 4d: Poprawiony kod z listingu 4c. |
Pisząc ostrzeżenia przed typowymi problemami, które niekoniecznie da się wykryć na etapie kompilacji, nie sposób nie wspomnieć o konieczności rozróżnienia sytuacji, w których można użyć std::move() od tych wymagających std::forward(). Ponieważ problem ten został opisany już szczegółowo w poprzednich akapitach, tu zostaną tylko krótko przypomniane zasady stosowalności. Jeśli, w momencie implementowania, znany jest pełny typ obiektu, który ma zostać przeniesiony można śmiało zastosować std::move(). Jeśli typy (lub ich części) są szablonami, konieczne jest zastosowanie std::forward(), który poprawnie rozróżni referencje prawostronne od lewostronnych, pozwalając uniknąć niemiłych niespodzianek.
W niniejszym odcinku cyklu artykułów poświęconych C++0x (a w zasadzie już C++11) przedstawione zostały referencje prawostronne. Niejako w parze z nimi pojawiła się możliwość stworzenia mechanizmu idealnego przekazywania parametrów, zmniejszającego narzuty czasowe i pamięciowe, przy jednoczesnym zachowaniu przejrzystości kodu. Zostały także omówione typowe przypadki użycia, w jakich nowe mechanizmy mogą zostać użyte. Przedstawione zostały też problemy i błędy logiczne, jakie programista może popełnić, oraz sposoby ich unikania.
Choć samo pojawienie się referencji rvalue wydaje się drobną modyfikacją, niesie ono ze sobą poważne konsekwencje. Wprowadza swojego rodzaju rewolucję w myśleniu o tworzeniu kodu w C++11. Przekazywanie parametrów i tworzenie obiektów tymczasowych nigdy nie było tak tanie.
Nowy mechanizm pozwala także na proste implementacje własnych klas do zarządzania dowolnymi zasobami, na wzór i podobieństwo unique_ptr. Deskryptory, gniazda, uchwyty pamięci dzielonej... Wszystkie te i inne elementy można teraz łatwo opakować i zarządzać nimi automatycznie (a więc bezpiecznie), w wyjątkowym stylu języka C++.
W kolejnym odcinku cyklu przedstawione zostaną wyrażenia lambda. Jest to kolejne, długo oczekiwane, rozszerzenie języka umożliwiające proste implementowanie krótkich funkcji pomocniczych, bezpośrednio w miejscu ich użycia. Nietypowe kryterium sortowania, czy operator wyszukiwania obiektu po danym parametrze w C++11 to tylko kilka znaków w kodzie źródłowym.
Bartosz Szurgot
Absolwent Informatyki wydziału Informatyki i Zarządzania Politechniki Wrocławskiej.
Obecnie pracuje we Wrocławskim Centrum Sieciowo-Superkomputerowym jako programista.
Główne zainteresowania techniczne to: programowanie, Linux, urządzenia wbudowane oraz elektronika.
W wolnym czasie tworzy oprogramowanie open-source oraz układy elektroniczne.
W C++ programuje od 9 lat.
Kontakt z autorem:
bartek.szurgot@baszerr.org
Strona domowa autora:
http://www.baszerr.org
Mariusz Uchroński
Absolwent Elektroniki i Telekomunikacji wydziału Elektroniki Politechniki
Wrocławskiej oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego.
Główne nurty zainteresowań technicznych to: programowanie oraz obliczenia HPC, a
w szczególności programowanie GPU w CUDA i OpenCL. W C++ programuje od 5 lat.
Kontakt z autorem:
mariusz.uchronski@gmail.com
Wojciech Waga
Absolwent Informatyki na wydziale Matematyki i Informatyki Uniwersytetu Wrocławskiego, obecnie słuchacz
studiów doktoranckich biologii molekularnej UWr oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego.
Programuje w C++ od 11 lat.
Kontakt z autorem:
wojciech.waga@gmail.com